winbrew_app\operations\install/
mod.rs

1//! End-to-end installation workflow for `winbrew install`.
2//!
3//! This module owns the full package installation pipeline once a package
4//! reference has been handed off by the CLI layer. The workflow is intentionally
5//! split into small submodules so each phase has a clear responsibility:
6//!
7//! - [`state`] manages database transitions and rejects conflicting installs.
8//! - [`download`] builds the network client and downloads the installer payload.
9//! - [`flow`] coordinates the download, engine execution, and rollback paths.
10//! - [`plan`] builds read-only install previews for the CLI.
11//! - [`types`] normalizes lower-level failures into user-facing install errors.
12//!
13//! The public entry point is [`run`]. It resolves the package reference against
14//! the catalog, selects the installer, creates a temporary workspace, streams
15//! download progress through [`InstallObserver`], and either commits the final
16//! install record or rolls back all partial state on failure.
17//!
18//! Checksum handling is strict by default. Legacy algorithms such as MD5 and
19//! SHA-1 are rejected unless the caller explicitly opts into
20//! `ignore_checksum_security`. When that flag is enabled, the accepted legacy
21//! algorithms are still returned in [`InstallOutcome`] so the caller can report
22//! what was tolerated during verification.
23
24use std::cell::RefCell;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use crate::catalog;
29use crate::core::network::installer_filename;
30use crate::core::paths::install_root_from_package_dir;
31use crate::core::temp_workspace;
32use crate::database;
33use crate::engines;
34use crate::models::catalog::CatalogInstaller;
35use crate::models::domains::shared::DeploymentKind;
36use crate::operations::shims;
37use tracing::warn;
38
39pub use crate::core::cancel;
40pub use crate::models::catalog::CatalogPackage;
41use crate::models::domains::command_resolution::{ResolverResult, resolve_command_exposure};
42use crate::models::domains::install::EngineInstallReceipt;
43pub use crate::models::domains::install::{InstallFailureClass, InstallOutcome, InstallResult};
44pub use crate::models::domains::package::PackageRef;
45use crate::models::domains::shared::HashAlgorithm;
46pub use types::InstallError;
47pub type Result<T> = types::Result<T>;
48
49pub mod download;
50pub mod flow;
51pub mod plan;
52mod sevenz;
53pub mod state;
54pub mod types;
55
56fn ensure_install_dirs(install_root: &Path) -> std::io::Result<()> {
57    fs::create_dir_all(install_root)
58}
59
60/// Interactive hooks used by the installation pipeline.
61///
62/// The install flow uses this trait for the pieces of user interaction it
63/// needs to support. Package selection is required; the remaining hooks are
64/// optional and default to no-op behavior so callers only override the phases
65/// they care about.
66pub trait InstallObserver {
67    /// Choose one package from the catalog matches returned for a reference.
68    ///
69    /// The callback receives the original query string and the resolved match
70    /// set. Return the index of the package to install from the provided slice.
71    fn choose_package(&mut self, query: &str, matches: &[CatalogPackage]) -> anyhow::Result<usize>;
72
73    /// Signal that installer download is about to start.
74    ///
75    /// `total_bytes` is `Some` when the server provided a content length and
76    /// `None` when the size is unknown ahead of time.
77    fn on_start(&mut self, _total_bytes: Option<u64>) {}
78
79    /// Report cumulative installer download progress in bytes.
80    fn on_progress(&mut self, _downloaded_bytes: u64) {}
81
82    /// Signal that the post-download install phase is starting.
83    fn on_install_start(&mut self, _message: &str) {}
84
85    /// Signal that the post-download install phase has completed.
86    ///
87    /// The engine work has finished, but the install flow may still continue
88    /// with commit, journal, and shim publication before returning.
89    fn on_install_complete(&mut self) {}
90
91    /// Confirm whether WinBrew may bootstrap a local 7-Zip runtime.
92    fn confirm_runtime_bootstrap(
93        &mut self,
94        runtime_name: &str,
95        target_dir: &Path,
96    ) -> anyhow::Result<bool> {
97        let _ = (runtime_name, target_dir);
98        Ok(false)
99    }
100}
101
102#[derive(Debug, Clone)]
103pub(crate) struct ResolvedInstallTarget {
104    pub package: CatalogPackage,
105    pub installer: CatalogInstaller,
106    pub command_resolution: ResolverResult,
107    pub resolved_commands: Option<Vec<String>>,
108    pub resolved_commands_json: Option<String>,
109    pub manifest_engine: crate::engines::EngineKind,
110    pub manifest_deployment_kind: DeploymentKind,
111    pub install_dir: PathBuf,
112    pub install_root: PathBuf,
113    pub temp_root: PathBuf,
114    pub download_path: PathBuf,
115    pub package_version: String,
116    pub runtime_bootstrap_required: bool,
117}
118
119/// Execute the full install workflow for a resolved package reference.
120///
121/// The function performs the following high-level steps:
122///
123/// 1. Resolve the package reference against the catalog.
124/// 2. Select an installer and build the engine-specific execution context.
125/// 3. Prepare the install target by rejecting conflicting database state and
126///    clearing stale failed records.
127/// 4. Mark the package as installing and create a temporary workspace rooted in
128///    the package/version pair.
129/// 5. Download and verify the installer while forwarding progress callbacks.
130/// 6. Hand the verified payload to the selected engine.
131/// 7. Roll back partial state on cancellation or failure, or mark the install
132///    as successful when the engine completes.
133///
134/// On success, the returned [`InstallOutcome`] contains the final install
135/// record plus any legacy checksum algorithms that were tolerated during
136/// verification. On error, the function maps the underlying failure into
137/// [`InstallError`] and makes a best effort to clean up database and filesystem
138/// artifacts before returning.
139pub fn run<O: InstallObserver>(
140    ctx: &crate::AppContext,
141    package_ref: PackageRef,
142    ignore_checksum_security: bool,
143    observer: &mut O,
144) -> Result<InstallOutcome> {
145    let observer = RefCell::new(observer);
146    let target = resolve_install_target(ctx, package_ref, |query, matches| {
147        observer.borrow_mut().choose_package(query, matches)
148    })?;
149
150    let _runtime_root_guard = sevenz::runtime_root_env_guard(&ctx.paths.root);
151    let mut conn = database::get_conn()?;
152    state::prepare_install_target_with_commands(
153        &conn,
154        &target.package.name,
155        &target.install_dir,
156        target.resolved_commands_json.as_deref(),
157    )?;
158
159    {
160        let mut observer = observer.borrow_mut();
161        sevenz::ensure_runtime(
162            &ctx.paths.root,
163            &target.installer.url,
164            |runtime_name, target_dir| observer.confirm_runtime_bootstrap(runtime_name, target_dir),
165        )?;
166    }
167
168    ensure_install_dirs(&target.install_root)?;
169    fs::create_dir_all(&target.temp_root)?;
170
171    let _temp_root_guard = TempRootGuard::new(target.temp_root.clone());
172    state::mark_installing(
173        &conn,
174        target.package.name.clone(),
175        target.package_version.clone(),
176        target.installer.kind,
177        target.manifest_deployment_kind,
178        target.manifest_engine,
179        &target.install_dir,
180    )?;
181
182    let client = download::build_client()?;
183
184    let (engine_receipt, legacy_checksum_algorithms) =
185        match (|| -> anyhow::Result<(EngineInstallReceipt, Vec<HashAlgorithm>)> {
186            let legacy_checksum_algorithms = download::download_installer(
187                &client,
188                &target.installer,
189                &target.download_path,
190                ignore_checksum_security,
191                |total_bytes| observer.borrow_mut().on_start(total_bytes),
192                |downloaded_bytes| observer.borrow_mut().on_progress(downloaded_bytes),
193            )?;
194
195            let resolved_kind =
196                engines::probe_installer_from_download(&target.installer, &target.download_path)?;
197            let mut resolved_installer = target.installer.clone();
198            resolved_installer.kind = resolved_kind;
199
200            let engine = engines::resolve_engine_for_installer(&resolved_installer)?;
201            let deployment_kind = engines::resolve_deployment_kind(&resolved_installer);
202
203            if resolved_kind != target.installer.kind
204                || engine != target.manifest_engine
205                || deployment_kind != target.manifest_deployment_kind
206            {
207                state::update_installing_identity(
208                    &conn,
209                    &target.package.name,
210                    resolved_kind,
211                    deployment_kind,
212                    engine,
213                )?;
214            }
215
216            observer
217                .borrow_mut()
218                .on_install_start(&format!("Installing {}...", target.package.name));
219            let _install_phase_guard = InstallPhaseGuard::new(&observer);
220
221            let engine_receipt = flow::execute_engine_install(
222                engine,
223                &resolved_installer,
224                &target.download_path,
225                &target.install_dir,
226                &target.package.name,
227            )?;
228
229            Ok((engine_receipt, legacy_checksum_algorithms))
230        })() {
231            Ok(result) => result,
232            Err(err) => {
233                let install_error: InstallError = err.into();
234
235                match install_error.failure_class() {
236                    InstallFailureClass::Cancelled => {
237                        flow::rollback_cancelled_install(
238                            &conn,
239                            &target.package.name,
240                            &target.install_dir,
241                        );
242                    }
243                    _ => {
244                        flow::rollback_failed_install(
245                            &conn,
246                            &target.package.name,
247                            &target.install_dir,
248                        );
249                    }
250                }
251
252                return Err(install_error);
253            }
254        };
255
256    if cancel::is_cancelled() {
257        flow::rollback_cancelled_install(&conn, &target.package.name, &target.install_dir);
258        return Err(cancel::CancellationError.into());
259    }
260
261    if let Err(err) = database::commit_install_with_commands(
262        &mut conn,
263        &target.package.name,
264        &engine_receipt,
265        target.resolved_commands_json.as_deref(),
266    ) {
267        let _ = state::mark_failed(&conn, &target.package.name);
268        if let Some(conflict) = err.downcast_ref::<database::CommandRegistryConflictError>() {
269            return Err(InstallError::CommandClaimedWhileInProgress {
270                command: conflict.command_name.clone(),
271            });
272        }
273        return Err(err.into());
274    }
275
276    if let Err(err) = write_install_journal(
277        &ctx.paths,
278        &conn,
279        &target.package.name,
280        &target.command_resolution,
281        target.resolved_commands.as_deref(),
282        target.package.bin.as_deref(),
283        target.package.env_add_path.as_deref(),
284    ) {
285        warn!(
286            package = %target.package.name,
287            error = %err,
288            "failed to write install journal"
289        );
290    }
291
292    if let Err(err) = shims::publish_package_shims(
293        &ctx.paths.shims,
294        &target.package.name,
295        target.package.bin.as_deref(),
296    ) {
297        warn!(
298            package = %target.package.name,
299            error = %err,
300            "failed to publish package shims"
301        );
302    }
303
304    let install_result = InstallResult {
305        name: target.package.name,
306        version: target.package_version,
307        install_dir: engine_receipt.install_dir.clone(),
308    };
309
310    Ok(InstallOutcome {
311        result: install_result,
312        legacy_checksum_algorithms,
313    })
314}
315
316pub(crate) fn resolve_install_target(
317    ctx: &crate::AppContext,
318    package_ref: PackageRef,
319    mut choose_package: impl FnMut(&str, &[CatalogPackage]) -> anyhow::Result<usize>,
320) -> Result<ResolvedInstallTarget> {
321    let catalog_conn = database::get_catalog_conn()?;
322    let package =
323        catalog::resolve_catalog_package_ref(&catalog_conn, &package_ref, |query, matches| {
324            choose_package(query, matches)
325        })?;
326    let selection_context = crate::catalog::SelectionContext::new(
327        crate::windows::host::host_profile(),
328        crate::windows::host::is_elevated(),
329    );
330    let installer = types::select_installer(
331        &database::get_installers(&catalog_conn, &package.id)?,
332        selection_context,
333    )?;
334    let command_resolution = resolve_command_exposure(&package, &installer)
335        .map_err(|source| InstallError::Unexpected(anyhow::Error::new(source)))?;
336    let resolved_commands = match &command_resolution {
337        ResolverResult::Resolved { commands, .. } => Some(commands.clone()),
338        ResolverResult::Unresolved { .. } => None,
339    };
340    let resolved_commands_json = resolved_commands.as_ref().map(|commands| {
341        serde_json::to_string(commands).expect("resolved commands should serialize")
342    });
343    let manifest_engine = engines::resolve_engine_for_installer(&installer)?;
344    let manifest_deployment_kind = engines::resolve_deployment_kind(&installer);
345    let package_version = package.version.to_string();
346    let install_dir = ctx.paths.package_install_dir(&package.name);
347    let install_root = install_root_from_package_dir(&install_dir);
348    let temp_root = temp_workspace::build_temp_root(&package.name, &package_version);
349    let download_path = temp_root.join(installer_filename(&installer.url));
350    let runtime_bootstrap_required =
351        sevenz::runtime_bootstrap_required(&ctx.paths.root, &installer.url);
352
353    Ok(ResolvedInstallTarget {
354        package,
355        installer,
356        command_resolution,
357        resolved_commands,
358        resolved_commands_json,
359        manifest_engine,
360        manifest_deployment_kind,
361        install_dir,
362        install_root,
363        temp_root,
364        download_path,
365        package_version,
366        runtime_bootstrap_required,
367    })
368}
369
370struct TempRootGuard {
371    path: PathBuf,
372}
373
374impl TempRootGuard {
375    fn new(path: PathBuf) -> Self {
376        Self { path }
377    }
378}
379
380struct InstallPhaseGuard<'a, O: InstallObserver> {
381    observer: &'a RefCell<&'a mut O>,
382}
383
384impl<'a, O: InstallObserver> InstallPhaseGuard<'a, O> {
385    fn new(observer: &'a RefCell<&'a mut O>) -> Self {
386        Self { observer }
387    }
388}
389
390impl<O: InstallObserver> Drop for InstallPhaseGuard<'_, O> {
391    fn drop(&mut self) {
392        self.observer.borrow_mut().on_install_complete();
393    }
394}
395
396#[cfg(test)]
397#[allow(clippy::items_after_test_module)]
398mod tests {
399    use super::write_install_journal;
400    use crate::database;
401    use crate::database::package_journal_key;
402    use crate::models::domains::command_resolution::{
403        CommandSource, Confidence, ResolverResult, VersionScope,
404    };
405    use crate::models::domains::install::InstallerType;
406    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
407    use anyhow::Result;
408    use std::fs;
409    use std::path::Path;
410    use winbrew_testing::{init_database, reset_install_state, test_root};
411
412    fn sample_package(name: &str, kind: InstallerType, install_dir: &Path) -> InstalledPackage {
413        InstalledPackage {
414            name: name.to_string(),
415            version: "1.0.0".to_string(),
416            kind,
417            deployment_kind: kind.deployment_kind(),
418            engine_kind: kind.into(),
419            engine_metadata: None,
420            install_dir: install_dir.to_string_lossy().into_owned(),
421            dependencies: Vec::new(),
422            status: PackageStatus::Ok,
423            installed_at: "2026-04-05T00:00:00Z".to_string(),
424        }
425    }
426
427    #[test]
428    fn write_install_journal_normalizes_single_string_bin_metadata() -> Result<()> {
429        let test_root = test_root();
430        let root = test_root.path();
431        let config = init_database(root)?;
432        reset_install_state(root)?;
433        let conn = database::get_conn()?;
434
435        let install_dir = root.join("packages").join("Contoso.Journal");
436        fs::create_dir_all(&install_dir)?;
437
438        let package = sample_package("Contoso.Journal", InstallerType::Portable, &install_dir);
439        database::insert_package(&conn, &package)?;
440
441        let paths = config.resolved_paths();
442        let command_resolution = ResolverResult::Resolved {
443            commands: vec!["contoso".to_string()],
444            confidence: Confidence::High,
445            sources: vec![CommandSource::PackageLevel],
446            version_scope: VersionScope::Specific(package.version.clone()),
447            catalog_fingerprint: "sha256:dummy".to_string(),
448        };
449        let commands = vec!["contoso".to_string()];
450
451        write_install_journal(
452            &paths,
453            &conn,
454            &package.name,
455            &command_resolution,
456            Some(commands.as_slice()),
457            Some(r#""bin/tool.exe""#),
458            Some(r#""env/add""#),
459        )?;
460
461        let journal_key = package_journal_key(&package.name, &package.version);
462        let journal_path = paths.package_journal_file(&journal_key);
463        let committed = database::JournalReader::read_committed_package(&journal_path)?;
464
465        assert_eq!(committed.commands, Some(vec!["contoso".to_string()]));
466        assert_eq!(committed.bin, Some(vec!["bin\\tool.exe".to_string()]));
467        assert_eq!(committed.env_add_path, vec![r"env\add".to_string()]);
468
469        Ok(())
470    }
471}
472
473impl Drop for TempRootGuard {
474    fn drop(&mut self) {
475        flow::cleanup_temp_root(&self.path);
476    }
477}
478
479fn write_install_journal(
480    paths: &crate::core::paths::ResolvedPaths,
481    conn: &crate::database::DbConnection,
482    package_name: &str,
483    command_resolution: &ResolverResult,
484    commands: Option<&[String]>,
485    bin: Option<&str>,
486    env_add_path: Option<&str>,
487) -> anyhow::Result<()> {
488    let committed_package = database::get_package(conn, package_name)?.ok_or_else(|| {
489        anyhow::anyhow!("package '{package_name}' was not found after a successful install commit")
490    })?;
491
492    let journal_key = database::package_journal_key(
493        committed_package.name.as_str(),
494        committed_package.version.as_str(),
495    );
496
497    fs::create_dir_all(paths.package_journal_dir(&journal_key))?;
498
499    let mut writer = database::JournalWriter::open_for_package_in(
500        paths,
501        committed_package.name.as_str(),
502        committed_package.version.as_str(),
503    )?;
504
505    let (bin, bin_bindings) = match bin {
506        Some(raw_bin) => match shims::parse_journal_shim_bindings(Some(raw_bin)) {
507            Ok(bin_bindings) => {
508                let bin = shims::target_paths_from_journal_bindings(&bin_bindings);
509                let bin = if bin.is_empty() { None } else { Some(bin) };
510                let bin_bindings = if bin_bindings.is_empty() {
511                    None
512                } else {
513                    Some(bin_bindings)
514                };
515                (bin, bin_bindings)
516            }
517            Err(err) => {
518                warn!(
519                    package = %package_name,
520                    error = %err,
521                    "failed to normalize install bin metadata into journal"
522                );
523                (None, None)
524            }
525        },
526        None => (None, None),
527    };
528
529    // env_add_path is recorded for replay and diagnostics, but WinBrew keeps
530    // command exposure on the managed shims path instead of synthesizing PATH
531    // entries from package install roots.
532    let env_add_path = match env_add_path {
533        Some(raw_env_add_path) => match shims::parse_target_paths(Some(raw_env_add_path)) {
534            Ok(env_add_path) => Some(env_add_path),
535            Err(err) => {
536                warn!(
537                    package = %package_name,
538                    error = %err,
539                    "failed to normalize install env_add_path metadata into journal"
540                );
541                None
542            }
543        },
544        None => None,
545    };
546
547    writer.append(&database::JournalEntry::Metadata {
548        package_id: committed_package.name.clone(),
549        version: committed_package.version.clone(),
550        engine: committed_package.engine_kind.as_str().to_string(),
551        deployment_kind: committed_package.deployment_kind,
552        install_dir: committed_package.install_dir.clone(),
553        dependencies: committed_package.dependencies.clone(),
554        commands: commands.map(|commands| commands.to_vec()),
555        bin,
556        bin_bindings,
557        env_add_path: env_add_path.unwrap_or_default(),
558        command_resolution: Some(command_resolution.clone()),
559        engine_metadata: committed_package.engine_metadata.clone(),
560    })?;
561    writer.append(&database::JournalEntry::Commit {
562        installed_at: committed_package.installed_at.clone(),
563    })?;
564    writer.flush()?;
565
566    Ok(())
567}